CloudFrontのELBオリジンへ直接アクセスする通信を制限する方法
2017年06月05日追記
ELBをカスタムオリジンにした構成時にIPアドレスを元に送信元を制限する方法はいろいろと問題が発生する可能性があります。以下のエントリに回避方法がまとめられているので、導入する際は参考にしてください。
はじめに
こんにちは、中山です。
CloudFrontのオリジンにパブリックなELBを設置する構成はよくあると思います。マルチオリジン構成で静的コンテンツはS3に、動的コンテンツはELBへという構成です。この構成における問題点としてよく聞くのが、CloudFrontを経由せずELBに直接アクセスさせたくないというものです。S3がオリジンの場合はオリジンアクセスアイデンティティを利用して、アクセスをCloudFrontにのみ制限することが可能です(詳細は[CloudFront + S3]特定バケットに特定ディストリビューションのみからアクセスできるよう設定する)。しかしELBの場合は現時点(2016/08/15)ではこういった機能はありません。ELBにはIAMポリシーを紐付けられないからです。また、ELBに紐付けるセキュリティグループで送信元をCloudFrontにできればよいのですが、そういった機能もありません。CloudFrontはVPC外で動作するため、セキュリティグループの送信元として指定できないからです。
今回この問題に対して2つの解決案を試してみました。それぞれ以下にご紹介します。
- CloudFrontのIPレンジを利用した制限
- CloudFrontのカスタムヘッダを利用した制限
CloudFrontのIPレンジを利用した制限
2016年12月1日追記
CloudFrontのリージョナルエッジキャッシュ導入により、CloudFrontで利用しているIPレンジが大幅に増えたようです。そのため、こちらのエントリで紹介しているスクリプトをそのまま実行してしまうとセキュリティグループのインバウンドルールデフォルト上限である50に引っかかってしまうようです。お使いになる際はご注意ください。ワークアラウンドとしては上限緩和申請などが考えられます。
CloudFrontのエッジロケーションで利用しているネットワークアドレスを、ELBのセキュリティグループの送信元に指定することにより、アクセスを制限する方法です。シンプルな発想なのでネット上にも散見される方法です。今回はAWSのGitHubにある以下のリポジトリをご紹介します。
CloudFrontのIPレンジを取得し、変更があった場合はセキュリティグループのインバウンドルールを更新するLambda関数です。CloudFrontのIPレンジはhttps://ip-ranges.amazonaws.com/ip-ranges.jsonで公開されています。このJSONファイルに変更が加わった場合、「arn:aws:sns:us-east-1:806199016981:AmazonIpSpaceChanged」というトピックにパブリッシュされます。詳細についてはこちらのドキュメントが詳しいです。このトピックをLambda関数の起動元に設定することで、最小限の起動回数で常に最新のIPレンジを参照できるというわけです。
使い方
例によってApexを使ったLambda関数のデプロイ用リポジトリを作りました。ご自由にお使いください。
aws-setup
というディレクトリにCloudFrontなどの検証用環境をセットアップするTerraformのコードを置いているので、お好みでご利用ください。デプロイ方法については以下のエントリを参照してください。
- ApexとTerraformでCloudWatch EventsによりInvokeされるLambda関数をデプロイする
- ApexとTerraformでCloudWatch Events Schedule x Lambda x SNS を設定する
動作確認
まずはLambda関数を実行する前の動作を確認してみます。CloudFrontとELBのドメインはご自身の環境に置き換えてください。
この時点のセキュリティグループは以下のように80番ポートがフルオープンになっています。
$ aws ec2 describe-security-groups \ --query 'SecurityGroups[?Tags[?Value==`cloudfront`]].IpPermissions' [ [ { "PrefixListIds": [], "FromPort": 80, "IpRanges": [ { "CidrIp": "0.0.0.0/0" } ], "ToPort": 80, "IpProtocol": "tcp", "UserIdGroupPairs": [] } ] ]
まずCloudFrontにアクセスしてみます。
$ curl -I <cloudfront-domain> HTTP/1.1 200 OK Content-Type: text/html Content-Length: 3770 Connection: keep-alive Accept-Ranges: bytes Date: Sun, 14 Aug 2016 04:34:34 GMT ETag: "575f1ada-eba" Last-Modified: Mon, 13 Jun 2016 20:43:06 GMT Server: nginx/1.8.1 X-Cache: Miss from cloudfront Via: 1.1 8c035d44fd4c5d1bea046227d56bc015.cloudfront.net (CloudFront) X-Amz-Cf-Id: uaY0-LLjfCzLFL2LKlh07IjPnxlFR_ED2GdK57zNHtaMwdTBkkS_ew==
アクセスできました。続いてELBに直接アクセスしてみます。
$ curl -I <elb-domain> HTTP/1.1 200 OK Accept-Ranges: bytes Content-Length: 3770 Content-Type: text/html Date: Sun, 14 Aug 2016 04:35:30 GMT ETag: "575f1ada-eba" Last-Modified: Mon, 13 Jun 2016 20:43:06 GMT Server: nginx/1.8.1 Connection: keep-alive
こちらもアクセス可能です。ではLambda関数を実行してセキュリティグループをアップデートしてみましょう。IPレンジが記載されたJSONファイルが更新されるとLambda関数を起動するSNSトピックから、以下のようなイベントが通知されます。
{ "Records": [ { "EventVersion": "1.0", "EventSubscriptionArn": "arn:aws:sns:EXAMPLE", "EventSource": "aws:sns", "Sns": { "SignatureVersion": "1", "Timestamp": "1970-01-01T00:00:00.000Z", "Signature": "EXAMPLE", "SigningCertUrl": "EXAMPLE", "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", "Message": "{\"create-time\": \"yyyy-mm-ddThh:mm:ss+00:00\", \"synctoken\": \"0123456789\", \"md5\": \"923813ed93b24942adbbda63882b8d0c\", \"url\": \"https://ip-ranges.amazonaws.com/ip-ranges.json\"}", "Type": "Notification", "UnsubscribeUrl": "EXAMPLE", "TopicArn": "arn:aws:sns:EXAMPLE", "Subject": "TestInvoke" } } ] }
これをファイルに保存してApexコマンドに渡してあげればLambda関数が実行されます。
$ cat test.json | apex invoke update_security_groups_lambda --logs START RequestId: 87ebcc81-6203-11e6-914a-078d8bc8d2a6 Version: 7 Received event: { "Records": [ { "EventVersion": "1.0", "EventSubscriptionArn": "arn:aws:sns:EXAMPLE", "EventSource": "aws:sns", "Sns": { "SignatureVersion": "1", "Timestamp": "1970-01-01T00:00:00.000Z", "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", "SigningCertUrl": "EXAMPLE", "Signature": "EXAMPLE", "Message": "{\"create-time\": \"yyyy-mm-ddThh:mm:ss+00:00\", \"synctoken\": \"0123456789\", \"md5\": \"923813ed93b24942adbbda63882b8d0c\", \"url\": \"https://ip-ranges.amazonaws.com/ip-ranges.json\"}", "Type": "Notification", "UnsubscribeUrl": "EXAMPLE", "TopicArn": "arn:aws:sns:EXAMPLE", "Subject": "TestInvoke" } } ] } Updating from https://ip-ranges.amazonaws.com/ip-ranges.json Found CLOUDFRONT range: 52.46.0.0/18 Found CLOUDFRONT range: 52.84.0.0/15 Found CLOUDFRONT range: 52.222.128.0/17 Found CLOUDFRONT range: 54.182.0.0/16 Found CLOUDFRONT range: 54.192.0.0/16 Found CLOUDFRONT range: 54.230.0.0/16 Found CLOUDFRONT range: 54.239.128.0/18 Found CLOUDFRONT range: 54.239.192.0/19 Found CLOUDFRONT range: 54.240.128.0/18 Found CLOUDFRONT range: 204.246.164.0/22 Found CLOUDFRONT range: 204.246.168.0/22 Found CLOUDFRONT range: 204.246.174.0/23 Found CLOUDFRONT range: 204.246.176.0/20 Found CLOUDFRONT range: 205.251.192.0/19 Found CLOUDFRONT range: 205.251.249.0/24 Found CLOUDFRONT range: 205.251.250.0/23 Found CLOUDFRONT range: 205.251.252.0/23 Found CLOUDFRONT range: 205.251.254.0/24 Found CLOUDFRONT range: 216.137.32.0/19 Found 1 SecurityGroups to update sg-c5c9fda1: Revoking 0.0.0.0/0:80 sg-c5c9fda1: Adding 52.46.0.0/18:80 sg-c5c9fda1: Adding 52.84.0.0/15:80 sg-c5c9fda1: Adding 52.222.128.0/17:80 sg-c5c9fda1: Adding 54.182.0.0/16:80 sg-c5c9fda1: Adding 54.192.0.0/16:80 sg-c5c9fda1: Adding 54.230.0.0/16:80 sg-c5c9fda1: Adding 54.239.128.0/18:80 sg-c5c9fda1: Adding 54.239.192.0/19:80 sg-c5c9fda1: Adding 54.240.128.0/18:80 sg-c5c9fda1: Adding 204.246.164.0/22:80 sg-c5c9fda1: Adding 204.246.168.0/22:80 sg-c5c9fda1: Adding 204.246.174.0/23:80 sg-c5c9fda1: Adding 204.246.176.0/20:80 sg-c5c9fda1: Adding 205.251.192.0/19:80 sg-c5c9fda1: Adding 205.251.249.0/24:80 sg-c5c9fda1: Adding 205.251.250.0/23:80 sg-c5c9fda1: Adding 205.251.252.0/23:80 sg-c5c9fda1: Adding 205.251.254.0/24:80 sg-c5c9fda1: Adding 216.137.32.0/19:80 sg-c5c9fda1: Added 19, Revoked 1 END RequestId: 87ebcc81-6203-11e6-914a-078d8bc8d2a6 REPORT RequestId: 87ebcc81-6203-11e6-914a-078d8bc8d2a6 Duration: 2758.25 ms Billed Duration: 2800 ms Memory Size: 128 MB Max Memory Used: 45 MB ["Updated sg-c5c9fda1", "Updated 1 of 1 SecurityGroups"]
セキュリティグループがアップデートされたようです。実際に確認してみます。
$ aws ec2 describe-security-groups \ --query 'SecurityGroups[?Tags[?Value==`cloudfront`]].IpPermissions' [ [ { "PrefixListIds": [], "FromPort": 80, "IpRanges": [ { "CidrIp": "52.46.0.0/18" }, { "CidrIp": "52.84.0.0/15" }, { "CidrIp": "52.222.128.0/17" }, { "CidrIp": "54.182.0.0/16" }, { "CidrIp": "54.192.0.0/16" }, { "CidrIp": "54.230.0.0/16" }, { "CidrIp": "54.239.128.0/18" }, { "CidrIp": "54.239.192.0/19" }, { "CidrIp": "54.240.128.0/18" }, { "CidrIp": "204.246.164.0/22" }, { "CidrIp": "204.246.168.0/22" }, { "CidrIp": "204.246.174.0/23" }, { "CidrIp": "204.246.176.0/20" }, { "CidrIp": "205.251.192.0/19" }, { "CidrIp": "205.251.249.0/24" }, { "CidrIp": "205.251.250.0/23" }, { "CidrIp": "205.251.252.0/23" }, { "CidrIp": "205.251.254.0/24" }, { "CidrIp": "216.137.32.0/19" } ], "ToPort": 80, "IpProtocol": "tcp", "UserIdGroupPairs": [] } ] ]
よさそうですね。CloudFrontにアクセスしてみます。
$ curl -I <cloudfront-domain> HTTP/1.1 200 OK Content-Type: text/html Content-Length: 3770 Connection: keep-alive Accept-Ranges: bytes Date: Sun, 14 Aug 2016 09:50:36 GMT ETag: "575f1ada-eba" Last-Modified: Mon, 13 Jun 2016 20:43:06 GMT Server: nginx/1.8.1 X-Cache: Miss from cloudfront Via: 1.1 eaaf7eb6072f1d7e0d8c7d2388e1bba6.cloudfront.net (CloudFront) X-Amz-Cf-Id: UbN-LENaucVyJHFfP8A0u5cGP8ctmxiJsco6OWNLMAECRo7FcU46WQ==
制限されずにアクセスできます。では、肝心のELBはどうでしょうか。
$ curl -I <elb-domain> curl: (7) Failed to connect to <elb-domain> port 80: Operation timed out
アクセスできずにタイムアウトしました。やりましたね。このLambda関数を動作させておけばELBへの直接アクセスを制限可能です。
CloudFrontのカスタムヘッダを利用した制限
CloudFrontにはカスタムヘッダという機能を使って、任意のヘッダをオリジンに渡すことが可能です。この機能を使えばELB配下のEC2上でヘッダをパースし、指定した文字列が入ってない場合はアクセスを制限することが可能です。今回は以下のドキュメントを参考に設定します。
設定手順
CloudFrontのカスタムヘッダに以下の設定をします。
ヘッダ | 値 |
---|---|
X-Cf-Secret | test1234 |
実際に利用される場合、ヘッダの値はより複雑な文字列にすることをオススメします。なお、通信経路を暗号化させヘッダの盗聴を防ぐためにCloudFront - ELB間の通信はHTTPSのみ許可するようにしてください。
ELB配下のEC2(Amazon Linux)上でこのヘッダをパースさせます。今回はNginxを利用します。デフォルトで用意されている /etc/nginx/nginx.conf
を以下のように修正してください。
$ diff -u nginx.conf.orig nginx.conf --- nginx.conf.orig 2016-06-14 05:43:06.000000000 +0900 +++ nginx.conf 2016-08-14 22:44:47.766506133 +0900 @@ -14,7 +14,8 @@ http { log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; + '"$http_user_agent" "$http_x_forwarded_for" ' + '"$http_x_cf_secret" "$is_not_elb" "$is_not_header_set" "$test"'; access_log /var/log/nginx/access.log main; @@ -40,6 +41,21 @@ server_name localhost; root /usr/share/nginx/html; + set $is_not_elb 0; + if ($http_user_agent != 'ELB-HealthChecker/1.0') { + set $is_not_elb 1; + } + + set $is_not_header_set 0; + if ($http_x_cf_secret != 'test1234') { + set $is_not_header_set 1; + } + + set $test $is_not_elb$is_not_header_set; + if ($test = '11') { + return 403; + } + # Load configuration files for the default server block. include /etc/nginx/default.d/*.conf;
修正内容は以下のとおりです。
- デバッグのために各種変数をログに出力
- ELBからのヘルスチェックとカスタムヘッダが設定されている場合、それぞれスイッチ変数をセット
- スイッチ変数が許可しない状態の場合403を返す
動作確認
まずはCloudFrontからアクセスしてみます。
$ curl -I https://<cloudfront-domain> HTTP/1.1 200 OK Content-Type: text/html Content-Length: 3770 Connection: keep-alive Accept-Ranges: bytes Date: Sun, 14 Aug 2016 13:47:57 GMT ETag: "575f1ada-eba" Last-Modified: Mon, 13 Jun 2016 20:43:06 GMT Server: nginx/1.8.1 X-Cache: Miss from cloudfront Via: 1.1 96275b125ac8a1ca0365ff6f864de90c.cloudfront.net (CloudFront) X-Amz-Cf-Id: aZyxo_ntaoTI49yTuCux6Dm0rrrX7TKM09Bxsns25FHhA416IKTYZg==
正常にアクセスできました。ログを確認すると以下のように test1234
という文字列がヘッダに渡されていることが確認できます。
172.16.1.221 - - [14/Aug/2016:22:47:57 +0900] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.43.0" "202.214.231.0, 54.239.196.162" "test1234" "1" "0" "10"
続いてELBに直接アクセスしてみましょう。
$ curl -I https://<elb-domain> HTTP/1.1 403 Forbidden Content-Length: 168 Content-Type: text/html Date: Sun, 14 Aug 2016 13:48:21 GMT Server: nginx/1.8.1 Connection: keep-alive
403が返ってきました。やりましたね。こちらは以下のようにログにカスタムヘッダが渡されていません。
172.16.1.221 - - [14/Aug/2016:22:48:21 +0900] "HEAD / HTTP/1.1" 403 0 "-" "curl/7.43.0" "202.214.231.0" "-" "1" "1" "11"
まとめ
いかがだったでしょうか。
2つの方法でバックエンドへの直接アクセスを制限する方法をご紹介しました。それぞれの方法を比較して表します。
実装方法 | 難易度 | 汎用性 | セキュリティ | 備考 |
---|---|---|---|---|
CloudFrontのIPレンジ | 低 | 高い | 並 | Lambda関数を動作させるだけなので簡単に実装可能。IPアドレスの詐称に対しては別途対策が必要かもしれません。 |
CloudFrontのカスタムヘッダ | やや高い | 低い | やや高い? | ミドルウェアの設定が必要なのでやや実装が難しい。また、ELBのヘルスチェックの制限を作りこむ必要がありそうです(ユーザエージェント判定だけだと微妙なので)。HTTPSで通信経路を秘匿しておけばカスタムヘッダの漏洩はあまりないと思います。 |
基本的にIPレンジによる制限を導入し、さらにセキュリティを強化したい場合はカスタムヘッダの導入を検討されるとよいかと思います。
本エントリがみなさんの参考になれば幸いです。
参考リンク
本エントリを書くに当たり以下のリポジトリの記載内容を参考にさせていただきました。ありがとうございました。